--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 2af89dc44673cd97bc35a7ff2a0360bb60233637
Parents : 93dd030
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-17T15:10:44-05:00
feat(network-visualiser): improve performance with adaptive chunk sizing and icon queue management
Changes
2 files changed, 182 insertions(+), 50 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index a56faf0d..9b7fd132 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -54,6 +54,41 @@ import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoading
import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
+/*
+ * Yields control back to the browser so it can paint, dispatch input events,
+ * and run other tasks. Prefers the prioritized task scheduler when available
+ * (Chromium 94+ / Electron) and falls back to a zero-delay timer everywhere
+ * else. setTimeout(0) is intentionally used over Promise.resolve() because
+ * microtasks do not give the renderer a chance to repaint.
+ */
+function yieldToMain() {
+ if (typeof window !== "undefined" && window.scheduler) {
+ if (typeof window.scheduler.yield === "function") {
+ return window.scheduler.yield();
+ }
+ if (typeof window.scheduler.postTask === "function") {
+ return new Promise((resolve) => {
+ window.scheduler.postTask(resolve, { priority: "user-blocking" });
+ });
+ }
+ }
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+/*
+ * Pick a visualisation chunk size that scales down on weak hardware. ARM SBCs
+ * commonly report 4 logical cores; phones/SoCs frequently report 2. We keep
+ * desktop throughput (larger chunks => fewer yields) but drop hard for low
+ * core-count devices so the main thread is not pinned for tens of ms per chunk.
+ */
+function pickAdaptiveChunkSize() {
+ const cores = (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4;
+ if (cores <= 2) return 40;
+ if (cores <= 4) return 80;
+ if (cores <= 6) return 150;
+ return 250;
+}
+
export default {
name: "NetworkVisualiser",
components: {
@@ -110,6 +145,10 @@ export default {
lastVizKeys: [],
vizHadOneLayout: false,
didDisableStabilization: false,
+ vizChunkSize: pickAdaptiveChunkSize(),
+ iconQueue: [],
+ iconQueueRunning: false,
+ iconQueueGeneration: 0,
};
},
computed: {
@@ -234,6 +273,8 @@ export default {
if (this.abortController) {
this.abortController.abort();
}
+ this.iconQueue = [];
+ this.iconQueueGeneration += 1;
if (this._toggleOrbitHandler) {
GlobalEmitter.off("toggle-orbit", this._toggleOrbitHandler);
}
@@ -1438,19 +1479,17 @@ export default {
strokeWidth: 4,
strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)",
},
- shadow: {
- enabled: true,
- color: "rgba(0,0,0,0.24)",
- size: 10,
- x: 0,
- y: 4,
- },
+ // Canvas shadows are by far the most expensive per-node
+ // operation in vis-network. Disable globally; the borders
+ // and circular-image rendering remain visually distinct.
+ shadow: false,
},
edges: {
- smooth: {
- type: "continuous",
- roundness: 0.5,
- },
+ // "continuous" computes bezier curves on every frame and
+ // is noticeably heavier than straight edges on slow ARM
+ // CPUs once you have a few hundred edges. Straight edges
+ // still look clean against the dotted background.
+ smooth: false,
selectionWidth: 3,
hoverWidth: 2,
color: {
@@ -1638,6 +1677,27 @@ export default {
this.loadingStatus = "Processing visualization...";
+ /*
+ * Invalidate any in-flight icon-generation work. Each call to
+ * processVisualization gets a new generation token; queued items
+ * carrying an older token are dropped when consumed so we do not
+ * paint canvases for nodes that no longer exist.
+ */
+ this.iconQueueGeneration += 1;
+ this.iconQueue = [];
+
+ /*
+ * Pause physics for the duration of the bulk update. Running the
+ * force-directed solver between chunks just churns the layout for
+ * a partial graph and pegs the main thread on slow CPUs. We
+ * restore the user's physics preference at the end so the final
+ * layout still settles naturally.
+ */
+ const physicsWasOn = this.network && this.enablePhysics;
+ if (physicsWasOn) {
+ this.network.setOptions({ physics: { enabled: false } });
+ }
+
const processedNodeIds = new Set();
const processedEdgeIds = new Set();
@@ -1850,8 +1910,15 @@ export default {
const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"];
- // Process in larger chunks for speed, but keep UI responsive
- const chunkSize = 250;
+ /*
+ * Chunk size is adaptive to hardwareConcurrency. Smaller chunks
+ * on weak hardware mean more frequent yields, which keeps the
+ * loading overlay animating and keeps input responsive at the
+ * cost of slightly higher total work due to extra event-loop
+ * round-trips. The trade-off massively favours smoothness on
+ * ARM SBCs.
+ */
+ const chunkSize = this.vizChunkSize;
this.totalBatches = Math.ceil(this.pathTable.length / chunkSize);
this.currentBatch = 0;
@@ -1946,14 +2013,30 @@ export default {
if (this.iconCache[cacheKey]) {
node.image = this.iconCache[cacheKey];
} else {
- node.image = await this.createIconImage(
- conversation.lxmf_user_icon.icon_name,
- conversation.lxmf_user_icon.foreground_colour,
- conversation.lxmf_user_icon.background_colour,
- 64
- );
+ /*
+ * Defer custom-icon generation. Painting the
+ * canvas + decoding the SVG inline used to
+ * serialise every chunk and was the dominant
+ * cause of the visualiser freezing on slow ARM
+ * CPUs. Use a sensible placeholder (the same
+ * default user image we use for icon-less lxmf
+ * nodes) and queue the real icon for async
+ * generation once all chunks are processed.
+ */
+ node.image =
+ entry.hops === 1
+ ? "/assets/images/network-visualiser/user_1hop.png"
+ : "/assets/images/network-visualiser/user.png";
+ this.iconQueue.push({
+ nodeId: node.id,
+ cacheKey,
+ iconName: conversation.lxmf_user_icon.icon_name,
+ fg: conversation.lxmf_user_icon.foreground_colour,
+ bg: conversation.lxmf_user_icon.background_colour,
+ size: 64,
+ generation: this.iconQueueGeneration,
+ });
}
- if (this.abortController.signal.aborted) return;
node.size = 30;
node._originalSize = 30;
} else {
@@ -2007,15 +2090,18 @@ export default {
processedEdgeIds.add(edgeId);
}
- // Update DataSet incrementally
if (batchNodes.length > 0) this.nodes.update(batchNodes);
if (batchEdges.length > 0) this.edges.update(batchEdges);
- // Allow UI to breathe and show progress
this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`;
- // Use nextTick for responsiveness
- await this.$nextTick();
+ /*
+ * Yield to the event loop using the prioritized scheduler
+ * (or setTimeout fallback). $nextTick is a microtask and does
+ * not let the renderer paint or process input between chunks,
+ * which is what was making the app feel frozen.
+ */
+ await yieldToMain();
if (this.abortController.signal.aborted) return;
}
@@ -2057,6 +2143,57 @@ export default {
if (this.enableOrbit) {
this.startOrbit();
}
+
+ /*
+ * Re-enable physics now that all nodes/edges are in place. The
+ * solver runs once on the final graph instead of repeatedly on
+ * partial states, which is dramatically cheaper.
+ */
+ if (physicsWasOn && this.network) {
+ this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ }
+
+ this.runIconQueue();
+ },
+ /*
+ * Drains the deferred lxmf custom-icon queue. Runs sequentially with
+ * a yield between each icon so painting many icons cannot pin the
+ * main thread the way the old inline-await version did. Items tagged
+ * with a stale generation (a newer processVisualization started while
+ * we were running) are skipped, as are nodes that no longer exist.
+ */
+ async runIconQueue() {
+ if (this.iconQueueRunning) return;
+ this.iconQueueRunning = true;
+ try {
+ while (this.iconQueue.length > 0) {
+ if (this.abortController.signal.aborted) return;
+ const item = this.iconQueue.shift();
+ if (item.generation !== this.iconQueueGeneration) {
+ continue;
+ }
+ if (!this.nodes.get(item.nodeId)) {
+ continue;
+ }
+ /*
+ * Queue items can collapse onto a single cached icon: if
+ * a previous iteration already painted this cacheKey we
+ * can short-circuit instead of re-invoking createIconImage
+ * (which would also redo the canvas+SVG decode work).
+ */
+ let url = this.iconCache[item.cacheKey];
+ if (!url) {
+ url = await this.createIconImage(item.iconName, item.fg, item.bg, item.size);
+ if (this.abortController.signal.aborted) return;
+ }
+ if (url && this.nodes.get(item.nodeId)) {
+ this.nodes.update({ id: item.nodeId, image: url });
+ }
+ await yieldToMain();
+ }
+ } finally {
+ this.iconQueueRunning = false;
+ }
},
},
};
diff --git a/tests/frontend/VisualizerOptimization.test.js b/tests/frontend/VisualizerOptimization.test.js
index 0a0618fb..9b36fe32 100644
--- a/tests/frontend/VisualizerOptimization.test.js
+++ b/tests/frontend/VisualizerOptimization.test.js
@@ -106,49 +106,38 @@ describe("NetworkVisualiser Optimization and Abort", () => {
expect(signal.aborted).toBe(true);
});
- it("stops processing visualization batches when aborted", async () => {
+ it("stops the deferred icon queue when aborted", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
- // Prepare large data
- wrapper.vm.pathTable = Array.from({ length: 1000 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
+ wrapper.vm.pathTable = Array.from({ length: 200 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
+ const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = {
destination_hash: cur.hash,
aspect: "lxmf.delivery",
display_name: "node",
+ lxmf_user_icon: { ...iconInfo, icon_name: `t${cur.hash}` },
};
return acc;
}, {});
+ wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
+ acc[cur.hash] = { lxmf_user_icon: { ...iconInfo, icon_name: `t${cur.hash}` } };
+ return acc;
+ }, {});
- // Add lxmf_user_icon to trigger await in createIconImage and slow it down
- const firstHash = wrapper.vm.pathTable[0].hash;
- wrapper.vm.announces[firstHash].lxmf_user_icon = {
- icon_name: "test",
- foreground_colour: "#000",
- background_colour: "#fff",
- };
- wrapper.vm.conversations[firstHash] = { lxmf_user_icon: wrapper.vm.announces[firstHash].lxmf_user_icon };
-
- // Mock createIconImage to be slow
- wrapper.vm.createIconImage = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(r, 100)));
-
- const processPromise = wrapper.vm.processVisualization();
+ wrapper.vm.createIconImage = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(() => r("blob:icon"), 50)));
- // Give it some time to start first batch and hit the await
- await new Promise((r) => setTimeout(r, 50));
+ await wrapper.vm.processVisualization();
- // It should be in batch 1 and stuck on createIconImage
- expect(wrapper.vm.currentBatch).toBe(1);
+ const callsBeforeAbort = wrapper.vm.createIconImage.mock.calls.length;
+ expect(wrapper.vm.iconQueue.length + callsBeforeAbort).toBeGreaterThan(0);
- // Abort
wrapper.vm.abortController.abort();
+ await new Promise((r) => setTimeout(r, 200));
- await processPromise;
-
- // Should have aborted and not reached the end where it resets currentBatch to 0
- // (Wait, actually if it returns early it stays 1)
- expect(wrapper.vm.currentBatch).toBe(1);
+ const callsAfterAbort = wrapper.vm.createIconImage.mock.calls.length;
+ expect(callsAfterAbort - callsBeforeAbort).toBeLessThanOrEqual(1);
});
it("parallelizes batch fetching", async () => {
@@ -278,9 +267,15 @@ describe("NetworkVisualiser Optimization and Abort", () => {
});
await wrapper.vm.processVisualization();
+ while (wrapper.vm.iconQueueRunning || wrapper.vm.iconQueue.length > 0) {
+ await new Promise((r) => setTimeout(r, 5));
+ }
expect(wrapper.vm.createIconImage).toHaveBeenCalledTimes(1);
await wrapper.vm.processVisualization();
+ while (wrapper.vm.iconQueueRunning || wrapper.vm.iconQueue.length > 0) {
+ await new Promise((r) => setTimeout(r, 5));
+ }
expect(wrapper.vm.createIconImage).toHaveBeenCalledTimes(1);
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────